Skip to content

fix: resolve conditional React Hook calls in Link and Script shims#223

Merged
southpolesteve merged 2 commits intocloudflare:mainfrom
haddoumounir:fix/rules-of-hooks-conditional-calls
Mar 3, 2026
Merged

fix: resolve conditional React Hook calls in Link and Script shims#223
southpolesteve merged 2 commits intocloudflare:mainfrom
haddoumounir:fix/rules-of-hooks-conditional-calls

Conversation

@haddoumounir
Copy link
Contributor

Summary

  • link.tsx: The dangerous-scheme early return was placed before all hooks (useState, useRef, useEffect, useCallback, useMemo), making them conditional. Moved the check to a boolean flag before hooks and the early return to after all hooks.
  • script.tsx: The SSR early return (typeof window === "undefined") was before useEffect, making it conditional. Moved useEffect before the SSR check since it never runs during SSR anyway.

Test plan

  • Verify dangerous scheme blocking still works in <Link> (e.g. javascript: hrefs render inert <a>)
  • Verify <Script> SSR rendering still works for beforeInteractive strategy
  • Verify no React warnings about conditional hooks in dev mode
  • Run existing test suite

Move early returns after all hook calls to satisfy the Rules of Hooks.

- link.tsx: the dangerous-scheme early return was before useState/useRef/
  useEffect/useCallback/useMemo — moved it to after all hooks
- script.tsx: the SSR early return was before useEffect — moved useEffect
  before the check since it never runs during SSR anyway
@southpolesteve
Copy link
Collaborator

/bigbonk review here please

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 3, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@223

commit: 0da8d5f

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch fixing the Rules of Hooks violations. Both changes are correct in principle — early returns before hooks are a real React rule violation that can cause bugs and warnings. Two comments below on the link shim approach.

// Apply locale prefix if specified (safe even for dangerous hrefs since we
// won't use the result when isDangerous is true)
const localizedHref = applyLocaleToHref(
isDangerous ? "/" : resolvedHref,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The substitution of "/" for the dangerous href is a reasonable way to keep applyLocaleToHref and withBasePath from operating on a javascript: string. However, this means localizedHref, fullHref, handleClick, and the prefetch useEffect all execute with a valid-looking "/" path when the href is actually dangerous.

This is fine today because:

  1. The early return at line 477 prevents the <a> from having an href or onClick/handleClick attached, so the prefetched "/" and the click handler are never used.
  2. The useEffect at line 317 will set up an IntersectionObserver for "/" and attempt to prefetch the root page — this is a harmless but unnecessary side effect.

Worth guarding the prefetch effect to avoid the wasted work:

Suggested change
isDangerous ? "/" : resolvedHref,
const localizedHref = applyLocaleToHref(
isDangerous ? "/" : resolvedHref,
locale,
);

Actually, the substitution itself is fine — but consider also skipping prefetch setup when isDangerous is true. You could add !isDangerous to the shouldPrefetch condition, or add an early return inside the prefetch useEffect. Minor nit though, not a blocker.

if (process.env.NODE_ENV !== "production") {
console.warn(`<Link> blocked dangerous href: ${resolvedHref}`);
}
return <a {...anchorProps}>{children}</a>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small behavioral difference from the original code: the old early return used restWithoutLocale (minus passHref) for the inert <a>, while this now uses anchorProps which is also restWithoutLocale minus passHref (defined at line 469). So the spread is equivalent — good.

One thing the old code did not pass through was ref and onClick. The new code also omits those (no ref={setRefs}, no onClick={handleClick}) which is correct for an inert element. Looks good.

// eslint-disable-next-line react-hooks/rules-of-hooks
// Client path: load scripts via useEffect based on strategy.
// useEffect never runs during SSR, so it's safe to call unconditionally.
useEffect(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the cleanest fix of the two. useEffect never fires during SSR, so moving it before the typeof window === "undefined" check is a no-op in terms of runtime behavior while satisfying the Rules of Hooks. The removed // eslint-disable-next-line react-hooks/rules-of-hooks suppression is also a good sign — the lint rule now passes without it.

Nicely done.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 3, 2026

Review posted. Here's a summary of my feedback on PR #223:

Overall: The PR correctly identifies and fixes two Rules of Hooks violations. Both changes are sound.

link.tsx: The approach of replacing the dangerous href with "/" before hooks and deferring the early return to after all hooks is correct. The anchorProps spread on the inert <a> is equivalent to the original restWithoutLocale minus passHref. I left a minor nit that the prefetch useEffect will now set up an IntersectionObserver for "/" when the href is dangerous — harmless but unnecessary work that could be skipped by incorporating isDangerous into the prefetch guard.

script.tsx: This is the cleaner of the two fixes. Since useEffect never fires during SSR, moving it before the typeof window === "undefined" check has zero runtime impact while satisfying the Rules of Hooks. The removed eslint suppression comment confirms the lint rule now passes naturally.

github run

@southpolesteve southpolesteve enabled auto-merge (squash) March 3, 2026 16:37
@southpolesteve southpolesteve merged commit eefee6d into cloudflare:main Mar 3, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants